Skip to content

fix(security): per-buyer idempotency, write scope, atomic async cache write - #482

Open
abojeEdwin wants to merge 3 commits into
enliven17:mainfrom
abojeEdwin:security/idempotency-keys-purchase-requests
Open

fix(security): per-buyer idempotency, write scope, atomic async cache write#482
abojeEdwin wants to merge 3 commits into
enliven17:mainfrom
abojeEdwin:security/idempotency-keys-purchase-requests

Conversation

@abojeEdwin

Copy link
Copy Markdown

Closes #396

Resubmission of #407, addressing all review blockers:

Review blockers fixed

  1. Conflicting unique index removed. The migration now drops the old
    provider-scoped (talosId, idempotencyKey) unique index and replaces it
    with the composite (talosId, requesterTalosId, idempotencyKey) index, so
    two different buyers can reuse the same key on one service without
    colliding. Schema, generated Drizzle schema, and the 0015 meta snapshot are
    kept in sync. Renumbered to 0018 to avoid clashing with main's
    0017_add_scoped_api_keys.

  2. Write scope. POST /api/talos/:id/service is a mutating purchase and
    now authenticates with commerce:write instead of commerce:read.

  3. Atomic async cache write. The async path wraps the job insert and the
    idempotencyResponse cache write in a single transaction via
    withTransactionRetry. A failed cache write now rolls back the insert
    instead of leaving an orphaned row that would turn every retry into a
    permanent 409.

Feature summary

Accept an optional Idempotency-Key header on POST /api/talos/:id/service
so safe retries after browser, network, or Stellar submission timeouts create
at most one job and one payment effect. The key is bound to the authenticated
buyer, target service, and request payload.

  • No header → processed normally (backward compatible)
  • New key → job created, response cached, X-Idempotent-Replayed: false
  • Same key + same payload → original cached 201 returned, replayed
  • Same key + different payload → 409 Conflict
  • Concurrent requests with same key → 409 "already being processed"
  • Different buyer with same key → allowed (scoped per buyer)

Tests

Idempotency suite expanded from 10 to 14 tests, adding regression coverage for:

  • commerce:write scope requirement on the purchase route
  • the migration index boundary (old index dropped, composite added)
  • atomic async cache write — failed cache write rolls back (no orphan 201)
  • recovery on retry after a failed cache write (no permanent 409)

Also verified: no new test failures and no new type errors vs. the merged
main baseline (the pre-existing failures in unrelated areas are unchanged).

Test Plan

  • pnpm dlx vitest run service-purchase-idempotency openapi-snapshot passes
  • Full web test run shows zero new failures vs. merged baseline
  • tsc --noEmit shows zero new type errors vs. merged baseline
  • eslint on changed files: 0 errors

Accept an optional Idempotency-Key header on POST /api/talos/:id/service
and persist the request/result association so safe retries after browser,
network, or Stellar submission timeouts create at most one job and one
payment effect.

Idempotency contract
────────────────────
- No header         → processed normally (backward compatible)
- New key           → job created, 201 response cached, X-Idempotent-Replayed: false
- Same key + same payload → original cached 201 returned, X-Idempotent-Replayed: true
- Same key + different payload → 409 Conflict
- Concurrent requests with same key → 409 'already being processed'
- Different buyer with same key → allowed (scoped per buyer)

Key is bound to the authenticated buyer, target service, and request
payload so it cannot be replayed across users or purchases.

Changes:
- Add Idempotency-Key header reading and 128-byte validation to service route
- Add idempotency check before payment verification/settlement
- Cache response body (idempotencyResponse) in same DB transaction as job insert
- Handle 23505 unique constraint violations for concurrent race conditions
- Add composite unique index (talosId, requesterTalosId, idempotencyKey) migration
- Update OpenAPI spec with Idempotency-Key parameter and idempotency contract docs
- Add 10 tests covering: new key, equivalent retry, payload conflict, in-flight,
  concurrent race (Promise.all), paymentSig replay, key length, and per-buyer scoping
…ache write

Address review blockers for service purchase idempotency:

- Drop the provider-scoped (talosId, idempotencyKey) unique index and
  replace it with the composite (talosId, requesterTalosId, idempotencyKey)
  index so two buyers can reuse the same key on one service. Renumbered
  migration to 0018 to avoid clashing with main's 0017_add_scoped_api_keys.

- POST /api/talos/:id/service is a mutating purchase; require the
  commerce:write scope instead of commerce:read.

- Make the async job insert + idempotencyResponse cache write atomic via a
  single transaction so a failed cache write rolls back the insert (no
  orphaned row, no permanent 409 on retry).

Adds regression tests for the write scope, the index boundary, and the
atomic async cache write (including recovery on retry).
@vercel

vercel Bot commented Aug 31, 2026

Copy link
Copy Markdown

@abojeEdwin is attempting to deploy a commit to the Cankat's projects Team on Vercel.

A member of the Team first needs to authorize it.

@abojeEdwin

Copy link
Copy Markdown
Author

Hi @enliven17, apologies for the delay
Kindly review my PR🙏🏾

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

security(web): add idempotency keys to service purchase requests

1 participant